/**
 * This file is a part of qloudgen-sec. 
 * You can redistribute qloudgen-sec and/or modify it under the terms of the Lesser GNU General Public License version 3. 
 * qloudgen-sec is distributed WITHOUT ANY WARRANTY. 
 *
 * See the Lesser GNU General Public License for more details at http://www.gnu.org/licenses/. 
 **/
package org.qloudgen.sec.jetty.nosql;

import java.util.Set;
import java.util.Map;
import java.util.HashMap;

import org.eclipse.jetty.nosql.NoSqlSession;
import org.eclipse.jetty.nosql.NoSqlSessionManager;
import org.eclipse.jetty.server.SessionIdManager;
import org.eclipse.jetty.util.annotation.ManagedAttribute;
import org.eclipse.jetty.util.annotation.ManagedObject;
import org.eclipse.jetty.util.annotation.ManagedOperation;
import org.eclipse.jetty.util.log.Log;
import org.eclipse.jetty.util.log.Logger;

/**
 * <p>Title: Cached session support</p>
 * <p>Description: Wrapper cached session manager for jetty cluster implementation.</p>
 * <p>Copyright: qloudgen/sec 2014</p>
 * @author <a href="mailto:hyq.dd.sun@gmail.com">glue.3M</a>
 *
 * Cached session manager using distributed cache as the storage.
 * The document model is an outer object that contains the elements:
 * <ul>
 *  <li>"id"      : session_id </li>
 *  <li>"created" : create_time </li>
 *  <li>"accessed": last_access_time </li>
 *  <li>"maxIdle" : max_idle_time setting as session was created </li>
 *  <li>"expiry"  : time at which session should expire </li>
 *  <li>"valid"   : session_valid </li>
 *  <li>"context" : a nested object containing many objects per context for which the session id is in use
 * </ul>
 * <p>
 * For example:
 * <code>
 * {  "id"                   : "w01ijx2vnalgv1sqrpjwuirprp7", 
 *    "accessed"             : NumberLong("1384818548903"), 
 *    "maxIdle"              : 1,
 *    "created"              : NumberLong("1384818548903"),
 *    "expiry"               : NumberLong("1384818549903"),
 *    "valid"                : true, 
 *    "context.::/contextA"  : { "A"            : "A", 
 *                               "__metadata__" : NumberLong(2) 
 *                             }, 
 *    "context.::/contextB"  : { "B"            : "B", 
 *                               "__metadata__" : NumberLong(1) 
 *                             }
 * }
 * </code>
 * </p>
 */
@ManagedObject( "Cached Session Manager" )
public abstract class AbstractCachedSessionManager extends NoSqlSessionManager {
	/**
	 * Log output.
	 */
	private final static Logger logger = Log.getLogger( AbstractCachedSessionManager.class );
	/**
	 * Session id
	 */
	public final static String __ID = "id";
	/**
	 * Time of session creation
	 */
	private final static String __CREATED = "created";
	/**
	 * Whether or not session is valid
	 */
	public final static String __VALID = "valid";
	/**
	 * Time at which session was invalidated
	 */
	public final static String __INVALIDATED = "invalidated";
	/**
	 * Last access time of session
	 */
	public final static String __ACCESSED = "accessed";
	/**
	 * Time this session will expire, based on last access time and maxIdle
	 */
	public final static String __EXPIRY = "expiry";
	/**
	 * The max idle time of a session (smallest value across all contexts which has a session with the same id)
	 */
	public final static String __MAX_IDLE = "maxIdle";
	/**
	 * Name of nested document field containing 1 sub document per context for which the session id is in use
	 */
	private final static String __CONTEXT = "context";
	/**
	 * Special attribute per session per context, incremented each time attributes are modified
	 */
	public final static String __VERSION = "__metadata__";
	
	/**
	 * the context id is only set when this class has been started
	 */
	private String _contextId = null;
	
	/**
	 * Default constructor.
	 */
	public AbstractCachedSessionManager() {
	}
	
	/**
	 * Returns current session.
	 *
	 * @param sessionId
	 * @return Map
	 */
	protected abstract Map getCacheSession(String sessionId);
	
	/**
	 * Removes current session.
	 *
	 * @param sessionId
	 * @return Map
	 */
	protected abstract Map removeCacheSession(String sessionId);
	
	/**
	 * Adds current session.
	 *
	 * @param sessionId
	 * @param session
	 * @return Map
	 */
	protected abstract Map putCacheSession(String sessionId , Map session);
	
	// ------------------------------------------------------------------------------
	
	/**
	 * Lifecycle for start.
	 */
	@Override
	public void doStart() throws Exception {
		//
		super.doStart();
		//
		String[] hosts = getContextHandler().getVirtualHosts();
		if ( hosts == null || hosts.length == 0 ) {
			// IPv6 equiv of 0.0.0.0
			hosts = new String[]{ "::" };
		}
		//
		String contextPath = getContext().getContextPath();
		if ( contextPath == null || "".equals( contextPath ) ) {
			contextPath = "*";
		}
		//
		_contextId = createContextId( hosts ,contextPath );
	}
	
	// ------------------------------------------------------------------------------
	
	/**
	 * Returns current context id.
	 * 
	 * @param virtualHosts
	 * @param contextPath
	 * @return
	 */
	private String createContextId(String[] virtualHosts , String contextPath) {
	    String contextId = virtualHosts[0] + contextPath;	    
	    contextId.replace( '/' , '_' );
	    contextId.replace( '.' , '_' );
	    contextId.replace( '\\' , '_' );
	    //
	    return contextId;
	}

	/**
	 * Returns current context key.
	 *
	 * @return String
	 */
	private String getContextKey() {
		return __CONTEXT + "." + _contextId;
	}
	
	/**
	 * Encode session attribute name.
	 *
	 * @param name
	 * @return String
	 */
    private String encodeName(String name) {
        return name.replace( "%" , "%25" ).replace( "." , "%2E" );
    }

	/**
	 * Decode session attribute name.
	 *
	 * @param name
	 * @return String
	 */
    private String decodeName(String name) {
        return name.replace( "%2E" , "." ).replace( "%25" , "%" );
    }

	// ------------------------------------------------------------------------------
	
	@ManagedOperation( value = "purge invalid sessions in the session store based on normal criteria" , impact = "ACTION" )
	public void purge() {
		( ( AbstractCachedSessionIdManager )_sessionIdManager ).purge();
	}
	
	@ManagedOperation( value = "full purge of invalid sessions in the session store" , impact = "ACTION" )
	public void purgeFully() {
		( ( AbstractCachedSessionIdManager )_sessionIdManager ).purgeFully();
	}
	
	@ManagedOperation( value = "scavenge sessions known to this manager" , impact = "ACTION" )
	public void scavenge() {
		( ( AbstractCachedSessionIdManager )_sessionIdManager ).scavenge();
	}
	
	@ManagedOperation( value = "scanvenge all sessions" , impact = "ACTION" )
	public void scavengeFully() {
		( ( AbstractCachedSessionIdManager )_sessionIdManager ).scavengeFully();
	}
	
	//@ManagedAttribute( "total number of known sessions in the store" )
	//public long getSessionStoreCount() {
	//	return _sessions.size();
	//}
	
	// ------------------------------------------------------------------------------
	
	/**
	 * Load session.
	 *
	 * @param clusterId
	 * @return NoSqlSession
	 */
	@Override
	protected NoSqlSession loadSession(String clusterId) {		
		logger.debug( "Current load session id is...{}" , clusterId );
		//
		Map<String, Object> tmp = getCacheSession( clusterId );
		logger.debug( "Current load session value is...{}" , tmp );
		if ( tmp == null ) {
			return null;
		}
		//
		Boolean valid = ( Boolean )tmp.get( __VALID );
		if ( valid == null || ! valid ) {
			return null;
		}
		//
		try {
			// Get context values
			Map<String , Object> c = ( Map<String , Object> )tmp.get( getContextKey() );
			if ( c != null ) {
				Long created = ( Long )tmp.get( __CREATED );
				Long accessed = ( Long )tmp.get( __ACCESSED );
				Object version = c.get( __VERSION );
				/**
				 * Create session.
				 */
				NoSqlSession session = new NoSqlSession( this , created , accessed , clusterId , version );
				for ( String name : c.keySet() ) {
					// Skip version metadata attribute which is not one of the actual session attributes.
					if ( __VERSION.equals( name ) ) {
					    continue;
					}
					//
					String attr = decodeName( name );
					Object value = c.get( name );
					//
					session.doPutOrRemove( attr , value );
					session.bindValue( attr , value );
				}
				/**
				 * Will do session activate.
				 */
				session.didActivate();
				//
				return session;
			}
			else {
				logger.debug( "Current load session context not exist...{}" , getContextKey() );
			}
		}
		catch (Exception e) {
			logger.warn( "Current load session error...\n{}" , e );
		}
		//
		return null;
	}
	
	/**
	 * Refresh session.
	 *
	 * @param session
	 * @param version
	 * @return Object
	 */
	@Override
	protected Object refresh(NoSqlSession session , Object version) {
		logger.debug( "Current refresh session id is...{}" , session.getClusterId() );
        //
		Map<String, Object> tmp = getCacheSession( session.getClusterId() );
		logger.debug( "Current refresh session old value is...{}" , tmp );
        /**
         * Check if our in memory version is the same as what is on the disk.
         */
        if ( version != null ) {
            if ( tmp != null ) {
				// Get context values
				Map<String , Object> c = ( Map<String , Object> )tmp.get( getContextKey() );
				if ( c != null ) {
					Object saved = c.get( __VERSION );
	        		if ( saved != null && saved.equals( version ) ) {
	        			logger.debug( "Current refresh session not needed...{}" , session.getClusterId() );
	        			return version;
	        		}
	        		version = saved;
	        	}
            }
        }
		// If it doesn't exist, invalidate.
		if ( tmp == null ) {
			logger.debug( "Current refresh session not exist...{}" , session.getClusterId() );
			//
			session.invalidate();
			return null;
		}
		// If it has been flagged invalid, invalidate.
		Boolean valid = ( Boolean )tmp.get( __VALID );
		if ( valid == null || ! valid ) {
			logger.debug( "Current refresh session exist but invalid...{}" , session.getClusterId() );
			//
			session.invalidate();
			return null;
		}
		/**
		 * We need to update the attributes. We will model this as a passivate, followed by bindings and then activation.
		 */
		session.willPassivate();
        /**
         * If we are here, we have to load the object.
         */
		try {
			//
			session.clearAttributes();
			// Get context values
			Map<String , Object> c = ( Map<String , Object> )tmp.get( getContextKey() );
			if ( c != null ) {
				// Load, add values to session.
				for ( String name : c.keySet() ) {
					// Skip version metadata attribute which is not one of the actual session attributes.
					if ( __VERSION.equals( name ) ) {
						continue;
					}
					//
					String attr = decodeName( name );
					Object value = c.get( name );
					// ???
					if ( c.keySet().contains( name ) ) {
						session.doPutOrRemove( attr , value );
						session.bindValue( attr , value );
					}
					else {
						session.doPutOrRemove( attr , value );
					}
				}
				// Cleanup, remove values from session, that don't exist in data anymore.
				for ( String name : session.getNames() ) {
					if ( ! c.keySet().contains( name ) ) {
						session.doPutOrRemove( name , null );
						session.unbindValue( name ,session.getAttribute( name ) );
					}
				}
			}
			/*
			 * We are refreshing so we should update the last accessed time.
			 */
			tmp.put( __ACCESSED , System.currentTimeMillis() );
			logger.debug( "Current refresh session new value is...{}" , tmp );
			//
			putCacheSession( session.getClusterId() , tmp );
			/**
			 *
			 */
			session.didActivate();
			//
			return version;
		}
		catch (Exception e) {
			logger.warn( "Current refresh session error...\n{}" , e );
		}
		//
		return null;
	}
	
	/**
	 * Save session.
	 *
	 * @param session
	 * @param version
	 * @param activateAfterSave
	 * @return Object
	 */
	@Override
	protected Object save(NoSqlSession session , Object version , boolean activateAfterSave) {
		logger.debug( "Current save session by...{}-->{}-->{}" , new Object[]{ session , version , activateAfterSave } );
		logger.debug( "Current save session id is...{}" , session.getClusterId() );
		//
		try {
			String key = session.getClusterId();
			/**
			 * Will do session passivate.
			 */
			session.willPassivate();
			/**
			 * Handle valid session.
			 */
			if ( session.isValid() ) {
				//
				long expiry = ( session.getMaxInactiveInterval() > 0 ? ( session.getAccessed() + ( 1000 * getMaxInactiveInterval() ) ) : 0 );
				/**
				 * Handle new session.
				 */
				if ( version == null ) {
					// Create version.
					version = new Long( 1 );
					// Create context values
					Map<String , Object> c = new HashMap<String, Object>();
					c.put( __VERSION , version );
					// Note dirty may include removed names
					Set<String> names = session.takeDirty();
					names.addAll( session.getNames() );
					for ( String name : names ) {
						Object value = session.getAttribute( name );
						if ( value != null ) {
							c.put( encodeName( name ) , value );
						}
					}
					// Create values.
					Map<String , Object> tmp = new HashMap<String, Object>();
					tmp.put( __ID , key );
					tmp.put( __CREATED , session.getCreationTime() );
					tmp.put( __VALID , true );
					tmp.put( __MAX_IDLE , getMaxInactiveInterval() );
					tmp.put( __EXPIRY , expiry );
					tmp.put( __ACCESSED , session.getAccessed() );
					tmp.put( getContextKey() , c );
					//
					logger.debug( "Current save session for added value is...{}" , tmp );
					//
					putCacheSession( key , tmp );
				}
				/**
				 * Handle exist session.
				 */
				else {
					// Session version will ++
					version = new Long( ( ( Number )version ).longValue() + 1 );
					//
					Map<String , Object> tmp = getCacheSession( key );
					logger.debug( "Current save session for updated old value is...{}" , tmp );
					//
					if ( tmp != null ) {
						// Get context values
						Map<String , Object> c = ( Map<String , Object> )tmp.get( getContextKey() );
						c.put( __VERSION , version );
						// Note dirty may include removed names
						Set<String> names = session.takeDirty();
						if ( isSaveAllAttributes() ) {
							names.addAll( session.getNames() );
						}
						for ( String name : names ) {
							Object value = session.getAttribute( name );
							if ( value != null ) {
								c.put( encodeName( name ) , value );
							}
							else {
								c.remove( encodeName( name ) );
							}
						}
						//
						tmp.put( getContextKey() , c );
						tmp.put( __ACCESSED , session.getAccessed() );
						//
						Integer currentMaxIdle = ( Integer )tmp.get( __MAX_IDLE );
						if ( currentMaxIdle != null && getMaxInactiveInterval() > 0 && getMaxInactiveInterval() < currentMaxIdle ) {
							tmp.put( __MAX_IDLE , getMaxInactiveInterval() );
						}
						//
						Long currentExpiry = ( Long )tmp.get( __EXPIRY );
						if ( currentExpiry != null && expiry > 0 && expiry < currentExpiry ) {
							tmp.put( __EXPIRY , currentExpiry );
						}
						//
						logger.debug( "Current save session for updated new value is...{}" , tmp );
						//
						putCacheSession( key , tmp );
					}
				}
			}
			/**
			 * Handle invalid session.
			 */
			else {
				//
				Map<String, Object> tmp = getCacheSession( key );
				logger.debug( "Current save session for invalid old value is...{}" , tmp );
				//
				if ( tmp != null ) {
					tmp.put( __VALID , false );
					tmp.put( __INVALIDATED , System.currentTimeMillis() );
					tmp.remove( getContextKey() );
					//
					logger.debug( "Current save session for invalid new value is...{}" , tmp );
					//
					putCacheSession( key , tmp );
				}
			}
			/**
			 * Will do session activate.
			 */
			if ( activateAfterSave ) {
				session.didActivate();
			}
			/**
			 * Return current version.
			 */
			return version;
		}
		catch (Exception e) {
			logger.warn( "Current save session error...\n{}" , e );
		}
		//
		return null;
	}
	
	/** 
	 * Remove the per-context sub document for this session id.
	 *
	 * @see org.eclipse.jetty.nosql.NoSqlSessionManager#remove(org.eclipse.jetty.nosql.NoSqlSession)
	 * @param session
	 * @return boolean
	 */
	@Override
	protected boolean remove(NoSqlSession session) {
		String key = session.getClusterId();
		String contextKey = getContextKey();
		//
		logger.debug( "Current remove session is...{}-->{}" , key , contextKey );
		//
		Map<String, Object> tmp = getCacheSession( key );
		logger.debug( "Current remove session old value is...{}" , tmp );
		//
		if ( tmp != null ) {
			tmp.remove( contextKey );
			logger.debug( "Current remove session new value is...{}" , tmp );
			//
			putCacheSession( key , tmp );
			return true;
		}
		else {
			return false;
		}
	}
	
	/**
	 * Change the session id. Note that this will change the session id for all contexts for which the session id is in use.
	 * 
	 * @see org.eclipse.jetty.nosql.NoSqlSessionManager#update(org.eclipse.jetty.nosql.NoSqlSession, java.lang.String, java.lang.String)
	 * @param session
	 * @param newClusterId
	 * @param newNodeId
	 * @throws Exception
	 */
	@Override
	protected void update(NoSqlSession session , String newClusterId , String newNodeId) throws Exception {
		//
		Map<String, Object> tmp = getCacheSession( session.getClusterId() );
		tmp.put( __ID , newClusterId );
		//
		logger.debug( "Current save session for update id new value is...{}" , tmp );
		//
		putCacheSession( newClusterId , tmp );
		//
		Map<String , Object> old = removeCacheSession( session.getClusterId() );
		logger.debug( "Current save session for update id old value is...{}" , old );
	}
	
	// ------------------------------------------------------------------------------
	
	/**
	 * Expire the session.
	 *
	 * @see org.eclipse.jetty.nosql.NoSqlSessionManager#expire(java.lang.String)
	 * @param idInCluster
	 */
	@Override
	protected void expire(String idInCluster) {
		//
		logger.debug( "Current expire session is...{}" , idInCluster );
		/**
		 * Expire the session for this context.
		 */
		super.expire( idInCluster );
		/**
		 *
		 */
		Map<String, Object> tmp = getCacheSession( idInCluster );
		logger.debug( "Current expire session old value is...{}" , tmp );
		//
		if ( tmp != null && ( Boolean )tmp.get( __VALID ) ) {
			tmp.put( __VALID , false );
			tmp.put( __INVALIDATED , System.currentTimeMillis() );
			//
			logger.debug( "Current expire session new value is...{}" , tmp );
			//
			putCacheSession( idInCluster , tmp );
		}
	}
}